studiocms
Version:
Astro Native CMS for AstroDB. Built from the ground up by the Astro community.
348 lines (284 loc) • 9.21 kB
text/typescript
import { User } from 'studiocms:auth/lib';
import { apiResponseLogger } from 'studiocms:logger';
import { Notifications } from 'studiocms:notifier';
import { SDKCore } from 'studiocms:sdk';
import { UserPermissionLevel } from '@withstudiocms/auth-kit/types';
import {
AllResponse,
createEffectAPIRoutes,
createJsonResponse,
Effect,
genLogger,
OptionsResponse,
parseAPIContextJson,
Schema,
} from '../../../../../effect.js';
import { verifyAuthTokenFromHeader } from '../../utils/auth-token.js';
type PermissionRank = 'visitor' | 'editor' | 'admin' | 'owner' | 'unknown';
export class JSONData extends Schema.Class<JSONData>('JSONData')({
rank: Schema.Union(
Schema.Literal('owner'),
Schema.Literal('admin'),
Schema.Literal('editor'),
Schema.Literal('visitor'),
Schema.Literal('unknown')
),
}) {}
export const { ALL, DELETE, GET, OPTIONS, PATCH } = createEffectAPIRoutes(
{
GET: (ctx) =>
genLogger('studioCMS:rest:v1:users:[id]:GET')(function* () {
const [sdk, user, userUtils] = yield* Effect.all([
SDKCore,
verifyAuthTokenFromHeader(ctx),
User,
]);
if (user instanceof Response) {
return user;
}
const { rank } = user;
if (rank !== 'owner' && rank !== 'admin') {
return apiResponseLogger(401, 'Unauthorized');
}
const { id } = ctx.params;
if (!id) {
return apiResponseLogger(400, 'Invalid form data, id is required');
}
const existingUser = yield* sdk.GET.users.byId(id);
if (!existingUser) {
return apiResponseLogger(404, 'User not found');
}
const { avatar, createdAt, email, name, permissionsData, updatedAt, url, username } =
existingUser;
const existingUserRank = (permissionsData?.rank ?? 'visitor') as PermissionRank;
const data = {
avatar,
createdAt,
email,
id,
name,
rank: existingUserRank,
updatedAt,
url,
username,
};
const loggedInUser = yield* sdk.GET.users.byId(user.userId);
if (!loggedInUser || loggedInUser === undefined) {
return apiResponseLogger(400, 'User Error');
}
const permissionLevelInput = {
isLoggedIn: true,
user: loggedInUser,
permissionLevel: (loggedInUser.permissionsData?.rank ?? 'visitor') as PermissionRank,
};
const userPermissionLevel = yield* userUtils.getUserPermissionLevel(permissionLevelInput);
const requiredPerms = () => {
switch (existingUserRank) {
case 'owner':
return UserPermissionLevel.owner;
case 'admin':
return UserPermissionLevel.admin;
case 'editor':
return UserPermissionLevel.editor;
case 'visitor':
return UserPermissionLevel.visitor;
default:
return UserPermissionLevel.unknown;
}
};
const isAllowed = userPermissionLevel > requiredPerms();
if (!isAllowed) {
return apiResponseLogger(401, 'Unauthorized');
}
return createJsonResponse(data);
}),
PATCH: (ctx) =>
genLogger('studioCMS:rest:v1:users:[id]:PATCH')(function* () {
const [sdk, user, userUtils, notifier] = yield* Effect.all([
SDKCore,
verifyAuthTokenFromHeader(ctx),
User,
Notifications,
]);
if (user instanceof Response) {
return user;
}
const { rank } = user;
if (rank !== 'owner' && rank !== 'admin') {
return apiResponseLogger(401, 'Unauthorized');
}
const { id } = ctx.params;
if (!id) {
return apiResponseLogger(400, 'Invalid form data, id is required');
}
const existingUser = yield* sdk.GET.users.byId(id);
if (!existingUser) {
return apiResponseLogger(400, 'User not found');
}
const { permissionsData } = existingUser;
const existingUserRank = (permissionsData?.rank ?? 'admin') as PermissionRank;
const loggedInUser = yield* sdk.GET.users.byId(user.userId);
if (!loggedInUser || loggedInUser === undefined) {
return apiResponseLogger(400, 'User Error');
}
const permissionLevelInput = {
isLoggedIn: true,
user: loggedInUser,
permissionLevel: (loggedInUser.permissionsData?.rank ?? 'visitor') as PermissionRank,
};
const userPermissionLevel = yield* userUtils.getUserPermissionLevel(permissionLevelInput);
const requiredPerms = () => {
switch (existingUserRank) {
case 'owner':
return UserPermissionLevel.owner;
case 'admin':
return UserPermissionLevel.admin;
case 'editor':
return UserPermissionLevel.editor;
case 'visitor':
return UserPermissionLevel.visitor;
default:
return UserPermissionLevel.unknown;
}
};
const isAllowed = userPermissionLevel > requiredPerms();
if (!isAllowed) {
return apiResponseLogger(401, 'Unauthorized');
}
const { rank: newRank } = yield* parseAPIContextJson(ctx, JSONData);
if (!newRank) {
return apiResponseLogger(400, 'Missing field: Rank is required');
}
// Prevent privilege escalation: actor cannot assign a rank >= their own
const requiredPermsForNewRank = (() => {
switch (newRank) {
case 'owner':
return UserPermissionLevel.owner;
case 'admin':
return UserPermissionLevel.admin;
case 'editor':
return UserPermissionLevel.editor;
case 'visitor':
return UserPermissionLevel.visitor;
default:
return UserPermissionLevel.unknown;
}
})();
if (userPermissionLevel <= requiredPermsForNewRank) {
return apiResponseLogger(403, 'Forbidden');
}
const updateRank = yield* sdk.UPDATE.permissions({
user: id,
rank: newRank,
});
if (!updateRank) {
return apiResponseLogger(400, 'Failed to update rank');
}
const updatedUser = yield* sdk.GET.users.byId(id);
if (!updatedUser) {
return apiResponseLogger(400, 'Failed to get updated user');
}
const {
avatar,
createdAt,
email,
name,
permissionsData: newPermissionsData,
updatedAt,
url,
username,
} = updatedUser;
const updatedUserRank = (newPermissionsData?.rank ?? 'unknown') as PermissionRank;
const data = {
avatar,
createdAt,
email,
id,
name,
rank: updatedUserRank,
updatedAt,
url,
username,
};
yield* notifier.sendUserNotification('account_updated', id);
yield* notifier.sendAdminNotification('user_updated', username);
return createJsonResponse(data);
}).pipe(Notifications.Provide),
DELETE: (ctx) =>
genLogger('studioCMS:rest:v1:users:[id]:DELETE')(function* () {
const [sdk, user, userUtils, notifier] = yield* Effect.all([
SDKCore,
verifyAuthTokenFromHeader(ctx),
User,
Notifications,
]);
if (user instanceof Response) {
return user;
}
const { rank } = user;
if (rank !== 'owner' && rank !== 'admin') {
return apiResponseLogger(401, 'Unauthorized');
}
const { id } = ctx.params;
if (!id) {
return apiResponseLogger(400, 'Invalid form data, id is required');
}
if (id === user.userId) {
return apiResponseLogger(400, 'Cannot delete your own account');
}
const existingUser = yield* sdk.GET.users.byId(id);
if (!existingUser) {
return apiResponseLogger(400, 'User not found');
}
const { permissionsData } = existingUser;
const existingUserRank = (permissionsData?.rank ?? 'admin') as PermissionRank;
const loggedInUser = yield* sdk.GET.users.byId(user.userId);
if (!loggedInUser || loggedInUser === undefined) {
return apiResponseLogger(400, 'User Error');
}
const permissionLevelInput = {
isLoggedIn: true,
user: loggedInUser,
permissionLevel: (loggedInUser.permissionsData?.rank ?? 'visitor') as PermissionRank,
};
const userPermissionLevel = yield* userUtils.getUserPermissionLevel(permissionLevelInput);
const requiredPerms = () => {
switch (existingUserRank) {
case 'owner':
return UserPermissionLevel.owner;
case 'admin':
return UserPermissionLevel.admin;
case 'editor':
return UserPermissionLevel.editor;
case 'visitor':
return UserPermissionLevel.visitor;
default:
return UserPermissionLevel.unknown;
}
};
const isAllowed = userPermissionLevel > requiredPerms();
if (!isAllowed) {
return apiResponseLogger(401, 'Unauthorized');
}
const response = yield* sdk.DELETE.user(id);
if (!response) {
return apiResponseLogger(400, 'Failed to delete user');
}
if (response.status === 'error') {
return apiResponseLogger(400, response.message);
}
yield* notifier.sendAdminNotification('user_deleted', existingUser.username);
return apiResponseLogger(200, response.message);
}).pipe(Notifications.Provide),
OPTIONS: () =>
Effect.try(() => OptionsResponse({ allowedMethods: ['GET', 'PATCH', 'DELETE'] })),
ALL: () => Effect.try(() => AllResponse()),
},
{
cors: { methods: ['GET', 'PATCH', 'DELETE', 'OPTIONS'] },
onError: (error) => {
console.error('API Error:', error);
return createJsonResponse({ error: 'Something went wrong' }, { status: 500 });
},
}
);